Skip to content

CooperateISO - #1498

Open
adrianhutter wants to merge 39 commits into
Axelrod-Python:devfrom
adrianhutter:CooperateISO
Open

adrianhutter wants to merge 39 commits into
Axelrod-Python:devfrom
adrianhutter:CooperateISO

Conversation

@adrianhutter

@adrianhutter adrianhutter commented Aug 5, 2026 •

Copy link
Copy Markdown

Adding 3 strategies (LongtermTfT, ISO, CooperateISO) from https://arxiv.org/abs/2303.03519.

Made a few changes relative to the versions used in the paper. Most importantly, ISO now uses scipy.optimize instead of torch's Adam. This works equally well, is faster, and avoids having to import torch.

Ran the CooperateISO implementation from this PR against all strategies in Axelrod-4.14.0, and verified that it outperforms EvolvedLookerUp2_2_2 and DBS at both noise levels 0% and 10%.

Fix Read the Docs build failures by updating documentation requirements and conf.py:

  • Pinned sphinx to <9.0.0 and added sphinx-rtd-theme in docs/requirements.txt to ensure compatibility and prevent theme lookup errors.
  • Removed scipy from the mock modules list in docs/conf.py so that submodules like scipy.optimize can be correctly imported from the installed package during documentation generation.

@adrianhutter
adrianhutter marked this pull request as ready for review August 5, 2026 12:15

@drvinceknight drvinceknight left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really great, I enjoyed the preprint (congrats on the work)!

My requests are mainly stylistic as we tend to try and avoid inline comments (there are a number in the tests that I didn't comment on that could perhaps be moved to docstrings).

Comment thread docs/requirements.txt Outdated
@@ -1,4 +1,7 @@
sphinx>=7.0.0,<9.0.0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you help me understand why this change is needed? (This might be because of an upstream change.)

Comment thread docs/conf.py

# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
on_rtd = os.environ.get("READTHEDOCS", None) == "True"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you help me understand why this change? (It might be something got stale that I'm not remembering.)

Comment thread axelrod/strategies/cooperate_iso.py Outdated
return opponent.history[-1]


# We describe memory-1 strategies as length-4 arrays, quantifying the probability of cooperation in the states [CC, CD, DC, DD].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# We describe memory-1 strategies as length-4 arrays, quantifying the probability of cooperation in the states [CC, CD, DC, DD].

Comment thread axelrod/strategies/cooperate_iso.py Outdated
Comment on lines +87 to +114
# Apply p_noise only to own strategy, not to opponent
# (the opponent strategy already includes noise effects).
own = my_strategy + p_noise * (1.0 - 2.0 * my_strategy)

# Flip CD/DC for opponent.
opp = opp_strategy[[0, 2, 1, 3]]

# Build the transition matrix.
trans_mat = np.array(
[
own * opp,
own * (1.0 - opp),
(1.0 - own) * opp,
(1.0 - own) * (1.0 - opp),
]
).T

R, P, S, T = RPST
rewards = np.array([R, S, T, P], dtype=float)

# Don't include init state in summed rewards.
inv = np.linalg.inv(np.eye(4) - (1.0 - p_end) * trans_mat)

# Calculate expected reward,
reward = init_state @ (inv @ rewards - rewards)

# Avg. reward per step
return p_end * float(reward) / (1.0 - p_end)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you remove this inline comments and put the corresponding explanation in the docstring please.

Comment thread axelrod/strategies/cooperate_iso.py Outdated
Comment on lines +130 to +148
# Clamp to possible values, given noise
opp = np.clip(opponent, p_noise, 1.0 - p_noise)

# Setup initial state
init_state = np.zeros(4, dtype=np.float32)
init_state[init_state_idx] = 1.0

# Define the objective function to minimize (negative reward)
def objective(params: np.ndarray) -> float:
return -get_reward(params, opp, init_state, p_end, p_noise, RPST)

x0 = np.array([0.5, 0.5, 0.5, 0.5])
bounds = [(0.0, 1.0), (0.0, 1.0), (0.0, 1.0), (0.0, 1.0)]
result = minimize(
objective, x0, method="L-BFGS-B", bounds=bounds, options={"maxiter": 50}
)

# result.fun is the minimum loss (-reward), result.x are the optimal parameters
return -result.fun, result.x

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove the inline comments here

Comment thread axelrod/strategies/cooperate_iso.py Outdated
Comment on lines +183 to +193
# Track the opponent's rate of cooperation (numerator, denominator) for each state.
# Assume we have seen the opponent play following TfT once in each state,
# to make the opponent-model well-defined from the start.
self.ewma_CC = [1.0, 1.0]
self.ewma_CD = [1.0, 1.0]
self.ewma_DC = [0.0, 1.0]
self.ewma_DD = [0.0, 1.0]

# Initial cooperation probabilities (num / den)
self.opp_model = [1.0, 0.0, 1.0, 0.0]
self.my_policy = [1.0, 0.0, 1.0, 0.0]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move these inline comments to the docstring of the __init__

Comment thread axelrod/strategies/cooperate_iso.py Resolved
@adrianhutter

Copy link
Copy Markdown
Author

@drvinceknight Thanks a lot for review. I addressed all comments, please take another look.

self.opp_model[0] = pr_c
elif prev_state == (C, D):
pr_c = self._update_single_ewma(self.ewma_CD, opp_act)
self.opp_model[2] = pr_c

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just checking that the index of 2 is correct here, since the ordering is different than the ewma fields and in the _get_state_idx function (also why is _get_state_idx not used here?).

It might be clearer to use dictionaries with the prev_state tuples as keys for both opp_model and ewma_**.

self.opp_model[2] = pr_c
elif prev_state == (D, C):
pr_c = self._update_single_ewma(self.ewma_DC, opp_act)
self.opp_model[1] = pr_c

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and here


Returns our expected reward per step.
"""
self._update_opponent_model(opponent)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If opponent.history is all that's used by these functions consider passing just that instead of the entire object

Returns our expected reward per step.
"""
self._update_opponent_model(opponent)
state_idx = self._get_state_idx(opponent)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and here

self.noise = self.match_attributes["noise"]
self.iso_instance.noise = self.noise

def _update_reward_history(self, opponent):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could simplify by computing a dict of (action, action) --> reward in __init__, then this is just self.reward_history.append(reward_dict[state]). This eliminates the repeated unpackings and if/elif chain.

players = [axl.Cooperator(), axl.Defector()]
tournament = axl.Tournament(players, turns=5, repetitions=3, noise=0.75)
tournament = axl.Tournament(
players, turns=5, repetitions=3, noise=0.75, seed=42

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this failing sometimes? Otherwise why set the seed?

(C, D),
(
C,
D,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove the extra comma (and just below) so that the formatting is consistent

return C
if len(self.history) == 1:
return opponent.history[-1]
if self.history[-2] == C:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for only updating z and n_tft_would_c on cooperation?

rewards = np.array([R, S, T, P], dtype=float)
inv = np.linalg.inv(np.eye(4) - (1.0 - p_end) * trans_mat)
reward = init_state @ (inv @ rewards - rewards)
return p_end * float(reward) / (1.0 - p_end)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this function break if p_end == 1 or p_noise == 1?

Discovers the optimal response strategy (policy) against a fixed opponent
model by maximizing the expected reward from a given starting state.
"""
assert p_noise < 0.5

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid the assert here so we don't break runs with p_noise >= 0.5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants